feat: gestures and software scroll inversion for MX Vertical - #783
feat: gestures and software scroll inversion for MX Vertical#783fuloskop wants to merge 5 commits into
Conversation
Greptile SummaryThe PR adds DPI-button gesture support for MX Vertical and macOS software scroll inversion for devices lacking native firmware support. It also adds conservative VID/PID conflict handling for indistinguishable directly attached mice, although offline twins currently block inversion for an active sibling.
Confidence Score: 4/5The PR is not yet safe to merge because an offline identical mouse can disable software inversion for the online mouse the user configured. The same-model conflict fix groups offline inventory entries with active devices, so an absent twin contributes a refusal and removes the online device's VID/PID from the hook inversion set. Files Needing Attention: crates/openlogi-agent-core/src/orchestrator.rs
|
| Filename | Overview |
|---|---|
| crates/openlogi-agent-core/src/orchestrator.rs | Builds software-inversion identities and resolves same-model conflicts, but an offline twin incorrectly blocks inversion for an online sibling. |
| crates/openlogi-agent-core/src/hook_runtime.rs | Selects software inversion by event VID/PID while excluding trackpads and otherwise preserving existing event handling. |
| crates/openlogi-hook/src/macos.rs | Implements in-place scroll-delta inversion while retaining native event metadata and precision. |
| crates/openlogi-device/src/session/gesture.rs | Adds DPI/ModeShift gesture sources and avoids overwriting an existing raw-XY diversion. |
| crates/openlogi-core/src/binding/button.rs | Classifies the DPI toggle as an HID++ gesture source while retaining its single-action default behavior. |
| crates/openlogi-desktop/src/state/scroll.rs | Exposes software inversion on macOS pointer devices while preserving native-capability gating elsewhere. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Device inventory and config] --> B[Group direct devices by VID/PID]
B --> C{All present twins agree?}
C -->|Yes| D[Publish identity in invert_scroll]
C -->|No| E[Drop shared identity]
D --> F[macOS hook receives wheel event]
F --> G{Matching mouse and not trackpad?}
G -->|Yes| H[Negate carried scroll deltas]
G -->|No| I[Pass through unchanged]
Reviews (5): Last reviewed commit: "Merge branch 'master' into feat/mx-verti..." | Re-trigger Greptile
The hook matches a scroll event by vendor/product id, which two identical directly-attached mice share, so one device's inversion setting cannot be applied without also applying it to its twin. Collapsing them inverted a wheel the user never configured, untraceable from the GUI. Group candidates by identity and drop any identity whose devices disagree, logging a warning instead; identities that agree still yield one entry. Reported by Greptile on AprilNEA#783.
0ed3e94 to
ac5df67
Compare
The hook matches a scroll event by vendor/product id, which two identical directly-attached mice share, so one device's inversion setting cannot be applied without also applying it to its twin. Collapsing them inverted a wheel the user never configured, untraceable from the GUI. Group candidates by identity and drop any identity whose devices disagree, logging a warning instead; identities that agree still yield one entry. Reported by Greptile on AprilNEA#783.
MX Vertical ships no dedicated gesture button. Its `0x1b04` list carries no `0x00c3` at all, its `0x00d7` (virtual gesture button) is listed but never emits an event, and `0x00fd` — the DPI/ModeShift switch — is the only control there flagged `raw-xy`. That is also the control Logitech's own software gestures from on this model, corroborated by the device's asset metadata, which ships `SLOT_NAME_GESTURE_*_BUTTON` markers next to `SLOT_NAME_DPI_BUTTON`. Add the DPI/ModeShift family to `GESTURE_SOURCE_BUTTONS` and to `ButtonId::is_hidpp_gesture_source`, so a gesture map bound to `ButtonId::DpiToggle` arms a raw-XY divert and dispatches swipes. DPI cycling remains the out-of-the-box behavior: `default_binding_for` returns `Single` for that button and a `Single` shape drops out of the gesture-map lookup, pinned by a test so it cannot regress silently. Also guard the capture session's DPI pass against re-arming a CID already armed as a raw-XY gesture source. That write carries no raw-XY and would strip the reporting the hold depends on — reachable exactly on a device whose DPI button *is* its gesture source. Verified on MX Vertical over Bluetooth-direct. Before: ten presses gave 19 `button=DpiToggle action=Cycle DPI Presets` events and no gestures. After: `gesture_sources=1 dpi_buttons=0`, and all four directions plus click dispatch their bound actions.
`map_slot_name` knew only `SLOT_NAME_MODESHIFT_BUTTON`, the MX Master line's name for that control. MX Vertical calls the same button `SLOT_NAME_DPI_BUTTON`, so it produced no hotspot: the device rendered with no editable control for its only extra button and could not be bound from the GUI at all. The five `SLOT_NAME_GESTURE_*_BUTTON` markers that model also ships stay unmapped on purpose — they mark the control the DPI marker already covers, this builder does not dedupe, and the picker renders swipe directions from the binding's gesture map. Both halves are pinned by tests.
Scroll inversion was gated on HID++ `0x2121` (HiResWheel) reporting an invert capability, so a device without that feature showed a permanently "Unavailable" toggle. MX Vertical reports 30 features and none is `0x2121`, putting the setting out of reach on that hardware. Add `EventDisposition::InvertScroll`. macOS negates the `CGEvent`'s delta fields in place, so the event keeps its phase, momentum and pixel precision and no synthetic replacement re-enters the tap. Only fields the event actually carries are negated: writing an axis the device left empty would introduce a delta an app reading that field would honour. The hook rewrites only devices in `HookMaps::invert_scroll`, which the orchestrator builds from the config while excluding natively-capable ones — for those the setting goes to the firmware, and rewriting on top would invert twice and cancel out. Trackpad scroll is never touched, since macOS already applies its own natural-scrolling preference there. Inversion is keyed per device rather than scoped to the selected one, so disabling the selected device empties the button maps without stopping another device's inversion. Both properties are covered by tests. evdev and `WH_MOUSE_LL` have no rewrite path yet; both pass the new disposition through rather than swallowing the scroll.
The hook matches a scroll event by vendor/product id, which two identical directly-attached mice share, so it cannot rewrite one without rewriting the other. Collapsing them applied one device's setting to its twin. Every reason to skip a device is now a refusal *on its identity* rather than an omission, and a single refusal drops the identity with a warning: a disabled device must stay untouched, and a natively-capable one already carries the setting in firmware, where rewriting on top would invert twice. An identity whose devices all agree still yields one entry. Reported by Greptile on AprilNEA#783.
ac5df67 to
0e4418c
Compare
| // Per identity: whether a device sharing it wants the rewrite, and | ||
| // whether another refuses. | ||
| let mut by_identity: BTreeMap<(u32, u32), (bool, bool)> = BTreeMap::new(); | ||
| for dev in &self.devices { |
There was a problem hiding this comment.
When two identical directly attached mice remain in inventory while one is offline, this loop still records the offline device as a refusal for their shared VID/PID. The identity is consequently removed from invert_scroll, leaving the online mouse's wheel uninverted despite its enabled software-inversion setting.
Knowledge Base Used: Background agent service
Summary
MX Vertical can use neither gestures nor scroll inversion today, for two unrelated reasons.
Gestures. The device ships no dedicated gesture button, and the capture layer knows only two gesture sources (
0x00c3, plus the MX Master 4 haptic panel).diag controlson real hardware:There is no
0x00c3.0x00d7(virtual gesture button) is listed but never emits — armed with raw-XY it produced no events across repeated presses.0x00fd, the DPI switch, is the control that actually fires, and it is raw-XY capable. Logitech's own asset metadata agrees: for this model it shipsSLOT_NAME_GESTURE_{UP,DOWN,LEFT,RIGHT,CLICK}_BUTTONnext toSLOT_NAME_DPI_BUTTON, i.e. it also treats the DPI button as the control that gestures here.The GUI had a second, independent problem:
map_slot_nameknew onlySLOT_NAME_MODESHIFT_BUTTON, so this device's DPI button produced no hotspot and could not be bound at all.Scroll inversion.
Capabilities::scroll_inversionis derived from0x2121(HiResWheel). MX Vertical reports 30 features and none is0x2121, so the toggle read "Unavailable" permanently with no way to reach the setting.Changes
openlogi-device— the DPI/ModeShift family joinsGESTURE_SOURCE_BUTTONS. Also guards the capture session's DPI pass against re-arming a CID already armed with raw-XY: that write carries none and would strip the reporting the hold depends on, reachable exactly on a device whose DPI button is its gesture source.openlogi-core—ButtonId::DpiToggleis now a HID++ gesture source. DPI cycling stays the out-of-the-box behavior:default_binding_forreturnsSinglefor that button, and aSingleshape drops out of the gesture-map lookup.openlogi-hook— newEventDisposition::InvertScroll. macOS negates theCGEventdelta fields in place, so the event keeps its phase, momentum and pixel precision and no synthetic replacement re-enters the tap. Only fields the event actually carries are negated, since writing an axis the device left empty would introduce a delta an app reading that field would honour. evdev andWH_MOUSE_LLhave no rewrite path yet and pass the disposition through rather than swallowing the scroll.openlogi-agent-core—HookMaps::invert_scrollcarries the identities the hook rewrites, built from the config while excluding natively-capable devices (their setting goes to the firmware; rewriting on top would invert twice). Trackpad scroll is never touched, since macOS already applies its own natural-scrolling preference. Inversion is keyed per device rather than scoped to the selected one, so disabling the selected device empties the button maps without stopping another device's inversion. Two identical directly-attached mice share the one vendor/product pair the hook can see, so when their settings disagree the identity is dropped with a warning rather than applied to both.openlogi-desktop—SLOT_NAME_DPI_BUTTONmaps toButtonId::DpiToggle(the five per-direction gesture markers stay unmapped: same control, and the hotspot builder does not dedupe). The scroll-inversion toggle is offered for pointer-capable devices on macOS, still gated on the native capability elsewhere.Testing
Commands run on the final tree, all clean:
646 tests pass. Eight new ones cover: DPI CIDs resolving to
ButtonId::DpiToggle, theSingledefault that keeps DPI cycling, both DPI slot names, the deliberately unmapped direction markers, exclusion of natively-capable and receiver-paired devices, the per-device inversion property, and the identical-device disagreement case.Hardware verification — runtime-tested on MX Vertical (Bluetooth-direct, macOS):
button=DpiToggle action=Cycle DPI Presets, zero gesturesgesture_sources=1 dpi_buttons=0; Left/Right/Up/Down and click all dispatch their bound actionsNot runtime-tested on Linux or Windows — those hook arms are compile-only changes that pass the new disposition through. Not tested with two identical mice attached; that path is covered by unit tests only.
Rebased onto
masterafter theopenlogi-deviceextraction and the hook backend-trait refactor; the only conflict was inmacos.rs, wherenegate_scroll_axisnow sits besideusable_scroll_deltaandstart()stays where the trait refactor put it.